Skip to content

Conversation

@romot-co
Copy link
Collaborator

@romot-co romot-co commented Nov 26, 2025

内容

ボリューム編集の編集UI側の実装を行います。
動作細部の調整・UI操作の流れの調整・見た目や補間など表示調整は別途とし、基本的な操作を確認できることを目的とします。

関連 Issue

ref #2733

スクリーンショット・動画など

test-volumeedit.mp4

その他

今回、お気持ちを書く -> AIくんのリポジトリをサーベイさせて仕様書として固める -> AIくんが実装
ほぼ一発になります...(すごい)

私が書いたお気持ち

# 実装ざっくり

ボリュームを編集できるようにすることで、
ユーザーが歌を望む形に調整できるようにします。

  • シーケンサと同時にボリュームの波形を下部に表示
  • 波形を変更できるようする

波形エンベロープを下部に表示して"描いて/つまんで"編集できればよく、
基本操作が直感・認知に沿った形で動くことを優先します。

大枠

既存のピッチ編集機能をベースとし、必要な部分だけ修正します。

ボリューム編集のデータ

現状リニアスケールになっていますが、対数スケールとします。
0-1の範囲とし、底はとりあえず-60dB・上は0dBとします。

欠損データ(APIからのデータがない・undefined)はVALUE_INDICATING_NO_DATAで埋めてください。

ボリューム編集の表示

ボリュームグラフ

SequencerVolumeEditor.vue内において、以下の要素を表示してください。

  1. APIから返却された元volume配列: 点線で表示
  2. 実際の編集済みボリュームデータ: 実線+下部までのエリア(半透明)で表示

添付の画像を参考にしてください。

編集済みボリュームの方がレイヤーZ軸上において上になるようにしてください。
元と一致している部分は重なって見え、違っている部分については背景に元ボリューム線が見える形です。
(認知としては実際のボリュームデータを操作しているように見えるように)

SequencerVolumeEditor縦幅を0-1の範囲に割り当て、
高さに対して相対的に表示するようにしてください。

横についてもズーム率を考慮した表示にしてください。

グリッド線

X軸については、ScoreSequencerと同じグリッド線を表示してください。
位置も同期している必要があります。

Y軸については、まずは必要ありません。

ボリューム編集操作のUIとその操作

ツールバー

ScoreSequencer側と同様のツールバーを表示してください。

編集(ペン)

  • ペンツールを選択時にドラッグでボリューム線を変更できるようにしてください。
  • ドラッグ中はボリュームグラフの変更が即時プレビューされて見えるようにしてください。
  • 端部でオートスクロールするようにする。

確定はドラッグ終了後とし、その時点でstoreへのコミットと、API通信を行ってください。

削除(消しゴム)

  • 消しゴムツールを選択時にドラッグでボリュームを削除できるようにしてください。
  • ドラッグ中はボリュームグラフの変更が即時プレビューされて見えるようにしてください。
  • 端部でオートスクロールするようにする。

確定はドラッグ終了後とし、その時点でstoreへのコミットと、API通信を行ってください。

ツール切り替え

  • ツールバー
  • コンテキストメニュー

で、表示を切り替えられるようにしてください。

ScoreSequencerとのスクロール位置同期

  • スクロール位置(viewportX軸相当の座標)

について同期している必要があります。
アプリワイドに持つ単一のエフェメラルなソースを参照して同期してください。
また、無用な再描画のトリガーとならないように、
Vueのライフサイクルを通じて更新するのではなく、変更後のイベント境界でVueに通知する形にしてください。

ScoreSequencerのスクロールはネイティブスクロールのため、
ネイティブスクロールをキャプチャして単一ソースに持つ必要があります。

状態の競合によるジッターを避けるようにしてください。
特に単一ソースの更新とVueのライフサイクルで更新された位置が細かく前後するような場合。

再生位置に対するオートスクロール

再生位置の同期も同様に、アプリ全体の単一ソースと同期してください。
再生位置に応じてオートスクロールされる必要があります。

エッジオートスクロール

編集ツール(カーソル)が端に来た場合、オートスクロールされる必要があります。
これもアプリ全体の単一ソースと同期し、ScoreSequencer側も同期スクロールするようにしてください。
useAutoScrollOnEdgeを使用してください。

再生時オートスクロールとの競合がありますが、再生時は編集不可で大丈夫です。

ScoreSequencerとのズーム同期

  • X軸ズーム

について同期している必要があります。
高頻度更新される必要はないので、storeの更新のみでよいです。

AIくんがリポジトリを調査してまとめた仕様書

ボリューム編集機能 実装仕様書

背景・目的

VOICEVOXソングエディターにおいて、ユーザーが歌声のボリューム(音量)を視覚的に編集できる機能を実装する。
ピッチ編集と同様の仕組みを利用し、必要部のみ変更する形で実装を行う。

元の要求仕様

  1. データ: 現状リニアスケールになっているが、dBスケールとする
  2. 表示: 元ボリューム(点線)と編集済みボリューム(実線+エリア)の2層表示
  3. 編集操作: ペンツールでの描画、消しゴムツールでの削除
  4. スクロール同期: ScoreSequencerとのX軸スクロール位置同期(単一ソース基準)
  5. ズーム同期: X軸ズームの同期(store参照)
  6. オートスクロール: 再生位置追従、エッジオートスクロール
  7. コンテキストメニュー: 右クリックでツール切り替え

1. データ仕様

1.1 スケール

項目
内部保存形式 リニア(振幅スケール、0以上)
表示形式 dB(デシベル)スケール
表示範囲 -60dB 〜 0dB
0dB超の扱い 0dBでクリップ(表示・編集とも最大1.0)

1.2 データソース

データ種別 ソース 説明
元ボリューム phraseQuery.volume APIから返却された元のボリューム配列
編集済みボリューム track.volumeEditData ユーザーが編集したボリュームデータ
プレビュー編集 previewVolumeEdit ドラッグ中のリアルタイムプレビュー

1.3 元ボリュームの入手とグローバル位置計算

元ボリュームはフレーズ生成後にphraseQuery.volumeから取得する。
タイムライン全体に敷き詰めるため、以下の計算が必要:

// グローバルフレーム位置の計算
const startFrame = Math.round(phrase.startTime * editorFrameRate);
const endFrame = startFrame + phraseQuery.volume.length;

フレーズ未生成区間の扱い:

  • VALUE_INDICATING_NO_DATAで埋める
  • 描画しない(線が途切れる)
  • ピッチ編集と同様のUX

1.4 volumeEditDataの扱い

storeのvolumeEditDataは編集した範囲だけを埋める仕様のため、
配列外アクセスでundefinedが返る可能性がある。

対応方針:

  • 表示前に全長をVALUE_INDICATING_NO_DATAで埋めた配列を作成
  • undefinedVALUE_INDICATING_NO_DATAの両方を「未編集」として扱う
// 表示用の全長配列を作成
function createFullLengthVolumeEditData(
  volumeEditData: number[],
  totalFrames: number
): number[] {
  const result = new Array(totalFrames).fill(VALUE_INDICATING_NO_DATA);
  for (let i = 0; i < volumeEditData.length; i++) {
    if (volumeEditData[i] !== undefined) {
      result[i] = volumeEditData[i];
    }
  }
  return result;
}

1.5 実際に利用されるボリューム(表示用)

編集済みボリュームと元ボリュームをマージしたデータ。
表示上の「編集済みボリューム線」はこのデータを使用する。

// フレームごとの実際に利用されるボリューム値を計算
function getEffectiveVolume(
  frame: number,
  volumeEditData: number[],
  originalVolume: number[]
): number {
  const editedValue = volumeEditData[frame];
  if (
    editedValue === undefined ||
    editedValue === VALUE_INDICATING_NO_DATA
  ) {
    // 編集データがない場合は元ボリュームを使用
    return originalVolume[frame] ?? VALUE_INDICATING_NO_DATA;
  }
  return editedValue;
}

1.6 座標変換

// リニア → dB変換(既存関数: src/sing/domain.ts)
function linearToDecibel(linear: number): number {
  if (linear === 0) return -1000;  // -∞として扱う
  return 20 * Math.log10(linear);
}

// dB → リニア変換(既存関数: src/sing/domain.ts)
function decibelToLinear(db: number): number {
  if (db <= -1000) return 0;
  return Math.pow(10, db / 20);
}

// 表示用定数
const MIN_DISPLAY_DB = -60;  // 表示上の最小dB値
const MAX_DISPLAY_DB = 0;    // 表示上の最大dB値(0dBでクリップ)

// dB → 正規化Y座標(0-1)
function dbToNormalizedY(db: number): number {
  if (db <= MIN_DISPLAY_DB) return 0;
  if (db >= MAX_DISPLAY_DB) return 1;
  return (db - MIN_DISPLAY_DB) / (MAX_DISPLAY_DB - MIN_DISPLAY_DB);
}

// 正規化Y座標 → dB
function normalizedYToDb(y: number): number {
  const clampedY = clamp(y, 0, 1);
  return MIN_DISPLAY_DB + clampedY * (MAX_DISPLAY_DB - MIN_DISPLAY_DB);
}

// リニア → 正規化Y座標(dB経由)
function linearToNormalizedY(linear: number): number {
  const db = linearToDecibel(linear);
  return dbToNormalizedY(db);
}

// 正規化Y座標 → リニア(dB経由)
function normalizedYToLinear(y: number): number {
  const db = normalizedYToDb(y);
  return decibelToLinear(db);
}

2. 表示仕様

2.1 表示要素

SequencerVolumeEditor.vue内で以下の要素を表示する。

2.1.1 元ボリューム線(Original Volume Line)

  • データソース: phraseQuery.volume(API返却値)
  • 表示スタイル: 点線
  • レイヤー順序: 背面(Z-index低)
  • : テーマ対応(まずはピッチと同じグレー系、後で調整)
  • 未生成区間: 描画しない(線が途切れる)

2.1.2 編集済みボリューム線(Edited Volume Line)

  • データソース: 実際に利用されるボリューム(元+編集のマージ)
  • 表示スタイル: 実線
  • レイヤー順序: 前面(Z-index高)
  • : テーマ対応(まずはピッチと同じグリーン系、後で調整)

2.1.3 ボリュームエリア(Volume Area)

  • データソース: 実際に利用されるボリューム(元+編集のマージ)
  • 表示スタイル: 編集済みボリューム線から下端までの半透明塗りつぶし
  • レイヤー順序: 編集済みボリューム線の直下
  • : 編集済みボリューム線と同系色、半透明

2.2 UX: 視覚的なフィードバック

【未編集部分】
- 編集済みボリューム線(実線)= 元ボリューム線(点線)と同じ位置
- 実線+エリアが点線を完全に覆い、ユーザーには実線+エリアのみが見える
- 「編集されていない」ことは視覚的に目立たない

【編集部分】
- 編集済みボリューム線(実線)と元ボリューム線(点線)の位置が異なる
- 実線+エリアの背景に元ボリューム線(点線)が透けて見える
- 「編集によって変化した」ことが視覚的に明確

2.3 レイヤー構造

[最前面] 編集済みボリューム線(実線)
[中間]   ボリュームエリア(半透明)
[背面]   元ボリューム線(点線)

2.4 縦軸マッピング

  • コンポーネント縦幅を0-1の範囲に割り当て
  • 0dB = 上端(Y=0)
  • -60dB = 下端(Y=height)
  • -60dB以下は底に張り付く

2.5 横軸マッピング

  • sequencerZoomXを考慮したスケーリング
  • offsetX(スクロール位置)を考慮した表示位置

2.6 プレビュー描画

  • スタイル: 編集済みボリュームと同じ(将来的に変更可能な設計)
  • 補間: まずは最近傍補間で実装、必要に応じて後で改善

3. グリッド線仕様

3.1 X軸グリッド線

  • ScoreSequencerと同じグリッド線を表示
  • 小節線・拍線を同期して表示
  • 位置も完全に同期

useSequencerGridPatternの活用

既存のuseSequencerGridコンポーザブル(src/composables/useSequencerGridPattern.ts)を活用する。

// useSequencerGridの戻り値
interface GridPattern {
  id: string;
  x: number;                    // パターン開始X座標
  timeSignature: TimeSignature;
  beatWidth: number;            // 1拍の幅(px)
  beatsPerMeasure: number;      // 1小節の拍数
  patternWidth: number;         // 1小節の幅(px)
  width: number;                // このパターンの総幅(px)
}

// 使用例
const gridPatterns = useSequencerGrid({
  timeSignatures: toRef(() => props.timeSignatures),
  tpqn: toRef(() => props.tpqn),
  sequencerZoomX: toRef(() => props.sequencerZoomX),
  numMeasures: toRef(() => props.numMeasures),
});

このデータを使用して小節線・拍線のX座標を計算し、PIXI.jsで描画する。

必要な情報(props経由で受け取り)

interface GridLineInfo {
  tpqn: number;
  timeSignatures: TimeSignature[];
  sequencerZoomX: number;
  numMeasures: number;
  offsetX: number;
}

3.2 Y軸グリッド線

  • 現時点では不要(将来的にdB目盛り線を追加可能)

4. 編集操作仕様

4.1 ペンツール(DRAW)

項目 仕様
選択条件 sequencerVolumeTool === 'DRAW'
操作 ドラッグでボリュームを変更
プレビュー ドラッグ中にリアルタイムで表示更新
確定タイミング マウスアップ時
確定処理 COMMAND_SET_VOLUME_EDIT_DATAをdispatch
オートスクロール 端部でuseAutoScrollOnEdgeによるスクロール
最大値制限 1.0(0dB)でクリップ

入力値の変換

// マウスY座標 → 正規化Y(0-1)→ dB → リニア値
const normalizedY = 1 - (mouseY / viewportHeight);
const clampedY = clamp(normalizedY, 0, 1);
const db = normalizedYToDb(clampedY);
const linearValue = Math.min(decibelToLinear(db), 1.0);  // 最大1.0でクリップ

4.2 消しゴムツール(ERASE)

項目 仕様
選択条件 sequencerVolumeTool === 'ERASE'
操作 ドラッグでボリューム編集を削除
プレビュー ドラッグ中にリアルタイムで表示更新
確定タイミング マウスアップ時
確定処理 COMMAND_ERASE_VOLUME_EDIT_DATAをdispatch
オートスクロール 端部でuseAutoScrollOnEdgeによるスクロール

削除の意味

  • 編集データをVALUE_INDICATING_NO_DATA(-1)で埋める
  • 表示上は元ボリューム(API返却値)に戻る

4.3 再生中の編集制限

  • 無効化箇所: parameterPanelStateMachine内でnowPlayingをチェック
  • 動作: 再生中はマウス入力を無視
  • UIフィードバック: 特になし(操作が効かないだけ)
// ステートマシン内での再生中チェック例
if (context.nowPlaying) {
  return;  // 入力を無視
}

5. コンテキストメニュー仕様

5.1 概要

右クリックでコンテキストメニューを表示し、ツール切り替えを可能にする。
ScoreSequencerのピッチ編集時のコンテキストメニュー実装を参考にする。

5.2 メニュー項目

const contextMenuData = computed<ContextMenuItemData[]>(() => [
  {
    type: "button",
    label: "ボリューム描画ツール",
    onClick: () => {
      contextMenu.value?.hide();
      void store.actions.SET_SEQUENCER_VOLUME_TOOL({
        sequencerVolumeTool: "DRAW",
      });
    },
    disableWhenUiLocked: false,
  },
  {
    type: "button",
    label: "ボリューム削除ツール",
    onClick: () => {
      contextMenu.value?.hide();
      void store.actions.SET_SEQUENCER_VOLUME_TOOL({
        sequencerVolumeTool: "ERASE",
      });
    },
    disableWhenUiLocked: false,
  },
]);

5.3 実装要件

  • ContextMenuコンポーネントを使用
  • @contextmenu.preventでデフォルトメニューを抑制
  • メニュー表示位置はマウス位置に追従

6. スクロール同期仕様

6.1 単一ソースアーキテクチャ

スクロール位置はアプリワイドな単一のソースで管理する。

┌─────────────────────────────────────────────────────────┐
│                    単一ソース                            │
│              (ScoreSequencer内のref)                    │
│                   scrollX: number                       │
└─────────────────────────────────────────────────────────┘
        ↑ キャプチャ                    ↓ 配信
        │                              │
┌───────┴───────┐              ┌───────┴───────┐
│ ScoreSequencer │              │ VolumeEditor  │
│ (ネイティブ     │  props経由   │ (参照のみ)     │
│  スクロール)    │ ──────────→ │               │
└───────────────┘              └───────────────┘

6.2 データフロー

  1. キャプチャ: ScoreSequencerのネイティブスクロールイベントからscrollXに書き込み
  2. 配信: scrollXをprops経由でVolumeEditorへ伝達
// ScoreSequencer.vue - キャプチャ
const onScroll = (event: Event) => {
  const scrollLeft = event.currentTarget.scrollLeft;
  scrollX.value = scrollLeft;
};

// VolumeEditor - 参照
// props.offsetX として受け取り、描画に使用

6.3 ネイティブスクロールに関する注釈

ブラウザのスクロール処理について

ネイティブスクロール(overflow: auto/scroll)はブラウザのコンポジタースレッドで
処理されるため、メインスレッドのJavaScript実行をブロックせずに滑らかにスクロールできる。

現在の実装ではScoreSequencerがネイティブスクロールを使用しており、
このスムーズなスクロール体験を維持することが重要。

VolumeEditorはネイティブスクロールを持たず、offsetX値に基づいてCanvas/PIXI座標を
変換することでスクロールを表現する。

6.4 jitter防止

  • スクロール位置の競合を避けるため、書き込み元はScoreSequencerのみとする
  • VolumeEditorからのスクロール要求は、ScoreSequencerのscrollTo()を呼び出す形で実現
  • 双方向の書き込みによる状態の競合を防止

6.5 将来のバーチャルスクロール対応

注意: 現時点ではバーチャルスクロールは実装しない

将来的にバーチャルスクロールへ移行する可能性を考慮し、以下の設計方針を採用:

  • スクロール位置は抽象化された単一ソースで管理
  • 各コンポーネントはスクロール位置を「受け取る」形で設計
  • 描画範囲の計算ロジックを分離可能な形で実装

バーチャルスクロール化時の変更箇所を最小限にするため、
現時点から疎結合な設計を心がける。


7. 再生位置オートスクロール

7.1 仕様

  • 再生位置に応じてオートスクロール
  • アプリ全体の単一ソース(playheadTicks)と同期

7.2 実装方針

ScoreSequencerの既存実装と同期:

  • ScoreSequencerがスクロールすると、その位置が単一ソース経由でVolumeEditorにも反映
  • VolumeEditor自体は独自にオートスクロールを制御しない

7.3 再生時の編集制限

  • 再生中は編集操作を無効化(ステートマシン内でチェック)
  • エッジオートスクロールとの競合を回避

8. エッジオートスクロール

8.1 仕様

  • 編集ツール(カーソル)が端に来た場合にオートスクロール
  • useAutoScrollOnEdgeコンポーザブルを使用
  • ScoreSequencer側も同期スクロール

8.2 実装方針

provide/injectでScoreSequencerのsequencerBodyをVolumeEditorから参照:

// ScoreSequencer.vue(または上位コンポーネント)
provide('sequencerBody', sequencerBody);

// VolumeEditor.vue
const sequencerBody = inject<Ref<HTMLElement | null>>('sequencerBody');

// useAutoScrollOnEdgeに渡す
useAutoScrollOnEdge(sequencerBody, enableAutoScrollOnEdge);

8.3 考慮事項

  • VolumeEditorとScoreSequencerは別パネル(QSplitter)
  • QSplitterを挟んでいるため、provideはScoreSequencer.vueより上位で行う
  • または、ScoreSequencer.vue内でprovideして、injectのスコープに注意

9. ズーム同期仕様

9.1 X軸ズーム

項目
ソース store.state.sequencerZoomX
更新頻度 低頻度(スライダー操作時のみ)
同期方式 storeの参照のみ

9.2 実装

// 両コンポーネントで同じ値を参照
const zoomX = computed(() => store.state.sequencerZoomX);

10. アーキテクチャ方針

10.1 PIXI世界とVue世界の分離

PIXI.js(Canvas描画)とVue(リアクティブシステム)を明確に分離する。

┌─────────────────────────────────────────────────────────┐
│                     Vue世界                              │
│  - リアクティブなデータ管理(ref, computed)              │
│  - コンポーネントのライフサイクル                         │
│  - イベントハンドリング(マウス、キーボード)              │
│  - 状態の監視(watch)                                   │
└─────────────────────────────────────────────────────────┘
                          │
                          │ データの受け渡し(単方向)
                          ↓
┌─────────────────────────────────────────────────────────┐
│                    PIXI世界                              │
│  - Canvas描画処理                                        │
│  - グラフィックオブジェクトの管理                         │
│  - requestAnimationFrameによる描画ループ                 │
│  - 座標変換・ジオメトリ計算                              │
└─────────────────────────────────────────────────────────┘

原則

  1. Vue → PIXI: データはVue世界からPIXI世界へ一方向に流す
  2. 更新トリガー: Vueのwatchで変更を検知し、PIXIの再描画フラグを立てる
  3. 描画タイミング: requestAnimationFrameでバッチ処理
  4. 状態の重複排除: 同じ状態をVueとPIXI両方で持たない

実装パターン(SequencerPitch.vue参照)

// Vue世界: データ監視
watch([volumeEditData, previewVolumeEdit], () => {
  renderInNextFrame = true;  // フラグを立てるだけ
});

// PIXI世界: 描画ループ
const callback = () => {
  if (renderInNextFrame) {
    render();  // 実際の描画処理
    renderInNextFrame = false;
  }
  requestId = requestAnimationFrame(callback);
};

10.2 描画クラスの分離

ボリューム描画用のクラスをsrc/sing/graphics/に配置:

src/sing/graphics/
├── pitchLine.ts      // 既存: ピッチライン描画
├── volumeLine.ts     // 新規: ボリュームライン描画
└── volumeArea.ts     // 新規: ボリュームエリア描画(検討)

volumeArea の検討

ボリュームエリア(半透明塗りつぶし)を別クラスとして分離するかの検討:

分離する場合のメリット:

  • ライン描画とエリア描画の責務分離
  • エリアのみの表示ON/OFF制御が容易
  • 将来的なスタイル変更に柔軟

統合する場合のメリット:

  • 同じデータを参照するため、コードの重複削減
  • 描画順序の管理が容易

推奨: 初期実装ではVolumeLineクラス内でエリアも描画し、
必要に応じて後から分離する。


11. 関連ファイル

11.1 修正対象ファイル

ファイル 修正内容
src/components/Sing/SequencerVolumeEditor.vue グラフ描画、dBスケール対応、スクロール同期、コンテキストメニュー
src/components/Sing/SequencerParameterPanel.vue scrollXの受け渡し
src/components/Sing/ScoreSequencer.vue scrollXをParameterPanelに渡す、sequencerBodyのprovide
src/sing/parameterPanelStateMachine/states/drawVolumeState.ts dBスケール対応、再生中チェック
src/sing/parameterPanelStateMachine/states/eraseVolumeState.ts 再生中チェック
src/sing/parameterPanelStateMachine/common.ts nowPlaying参照の追加(必要に応じて)

11.2 参考ファイル

ファイル 参考内容
src/components/Sing/SequencerPitch.vue グラフ描画、PIXI/Vue分離パターン、元データ結合ロジック
src/sing/graphics/pitchLine.ts PIXI.jsでのライン描画
src/components/Sing/SequencerGrid/Presentation.vue グリッド線の描画
src/composables/useSequencerGridPattern.ts グリッドパターン計算
src/composables/useAutoScrollOnEdge.ts エッジオートスクロール
src/sing/domain.ts linearToDecibel, decibelToLinear
src/components/Menu/ContextMenu/Container.vue コンテキストメニューコンポーネント

11.3 新規作成ファイル

ファイル 内容
src/sing/graphics/volumeLine.ts ボリュームライン+エリア描画クラス

12. 実装順序

Phase 1: 基盤整備
├── 1.1 scrollXの伝達経路構築
│     ScoreSequencer → ParameterPanel → VolumeEditor
├── 1.2 sequencerBodyのprovide/inject設定
├── 1.3 numMeasuresの伝達(inject/provide活用)
└── 1.4 dBスケール変換ユーティリティの整備

Phase 2: グラフ描画
├── 2.1 PIXI.js描画基盤の構築(SequencerPitch.vue参照)
├── 2.2 VolumeLine描画クラスの作成
├── 2.3 元ボリュームデータの結合ロジック(フレーズ→グローバル位置)
├── 2.4 元ボリューム線(点線)の描画
├── 2.5 編集済みボリューム線(実線)の描画
└── 2.6 ボリュームエリア(半透明)の描画

Phase 3: グリッド線
└── 3.1 useSequencerGridを使用したX軸グリッド線の描画

Phase 4: 編集操作のdB対応
├── 4.1 drawVolumeStateのdBスケール対応
├── 4.2 座標変換ロジックの修正(0dBクリップ含む)
├── 4.3 プレビュー表示の更新
└── 4.4 再生中の編集無効化(nowPlayingチェック)

Phase 5: コンテキストメニュー
└── 5.1 右クリックメニューの実装(ツール切り替え)

Phase 6: オートスクロール
├── 6.1 エッジオートスクロールの実装(provide/inject経由)
├── 6.2 再生時オートスクロールとの連携
└── 6.3 再生時の編集無効化確認

13. 注意事項

13.1 パフォーマンス

  • 高頻度更新(マウス移動等)でのVue再描画を最小限に
  • Canvas/PIXI.jsでの直接描画を活用
  • requestAnimationFrameでのバッチ更新
  • PIXI世界とVue世界の境界を明確に

13.2 テーマ対応

  • ライト/ダークテーマで色を切り替え
  • 既存のisDarkcomputed値を参照
  • 色定義はまずピッチと同じ、後で調整

13.3 既存機能との整合性

  • ピッチ編集と同様のUX
  • 同じツールパレットUIパターン
  • 同じステートマシン構造

13.4 バーチャルスクロール非実装

  • 現時点ではバーチャルスクロールは実装しない
  • 将来の移行を考慮した設計は維持

13.5 エッジケース

  • フレーズ未生成区間: 描画しない
  • volumeEditData配列外: VALUE_INDICATING_NO_DATAとして扱う
  • 0dB超の値: 0dBでクリップ
  • -60dB以下の値: -60dB(底)に張り付く

@romot-co romot-co marked this pull request as draft November 26, 2025 23:08
@voicevox-preview-pages
Copy link

voicevox-preview-pages bot commented Jan 8, 2026

🚀 プレビュー用ページを作成しました 🚀

更新時点でのコミットハッシュ:edfcba0

@romot-co
Copy link
Collaborator Author

romot-co commented Jan 8, 2026

いったん年末の変更点を取り入れ済み

プルリクとして分割する粒度は本日相談

今のプルリクのスコープ外での残作業

  1. Y軸グリッドの表示
  2. 編集位置ツールチップの表示
  3. 補間(ガタガタになるのを避ける)
  4. ツール系の位置や扱いをどうするか
  5. 見た目の調整

@romot-co romot-co marked this pull request as ready for review January 8, 2026 04:19
@romot-co romot-co requested a review from Copilot January 8, 2026 04:19
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request implements the UI-side functionality for volume editing in the song editor, enabling users to visually edit volume envelopes. The implementation is based on the existing pitch editing functionality with adaptations for volume-specific requirements including dB scale display and rendering.

Key changes include:

  • Addition of PIXI.js-based volume graph rendering with original and edited volume lines
  • Integration of volume editing state machine with play state awareness
  • Implementation of tool palette for draw/erase operations with context menu support
  • Enhanced auto-scroll functionality to support out-of-bounds cursor clamping

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
src/type/preload.ts Adds parameterPanelHeight to splitter position schema for persisting panel size
src/sing/parameterPanelStateMachine/states/drawVolumeState.ts Removes negative frame check to allow processing at boundary coordinates
src/sing/parameterPanelStateMachine/common.ts Adds nowPlaying state to parameter panel context for play-time edit prevention
src/sing/graphics/volumeLine.ts New graphics class for rendering volume lines (dashed/solid) and filled areas
src/composables/useParameterPanelStateMachine.ts Exposes nowPlaying computed ref for state machine
src/composables/useAutoScrollOnEdge.ts Adds clampOutsideX/Y options to support continued scrolling when cursor exits panel
src/components/Sing/SequencerVolumeToolPalette.vue New component providing draw/erase tool selection UI
src/components/Sing/SequencerVolumeEditor.vue Major refactor implementing full volume editor with PIXI rendering, grid, and editing operations
src/components/Sing/SequencerParameterPanel.vue Simplified to pass offsetX prop to volume editor
src/components/Sing/ScoreSequencer.vue Adds scroll position forwarding, sequencer body injection, and panel height persistence
src/components/Menu/ContextMenu/Container.vue Exposes show method for programmatic context menu display

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


// TODO: 補間処理を実装する...表示含めスケールを先に決める必要ありそう
// まずはUIが動くようにのみする
const cursorFrame = this.currentCursorPos.frame;
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The removal of the negative frame check allows processing negative frame values, which could lead to unexpected behavior. While the UI may clamp coordinates, the state machine should validate input data. Consider whether negative frames are intentionally supported or if this check should be retained with proper handling.

Suggested change
const cursorFrame = this.currentCursorPos.frame;
const rawCursorFrame = this.currentCursorPos.frame;
// Guard against negative frame values to avoid unexpected behavior.
if (rawCursorFrame < 0) {
return;
}
const cursorFrame = rawCursorFrame;

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

起こらない気もするが、念のため確認する。

Comment on lines +122 to +157
for (let i = 1; i < screenPoints.length; i++) {
let x0 = screenPoints[i - 1].x;
let y0 = screenPoints[i - 1].y;
const x1 = screenPoints[i].x;
const y1 = screenPoints[i].y;
let segLen = Math.hypot(x1 - x0, y1 - y0);
while (segLen > 0.0001) {
const need = drawing ? dashRemaining : gapRemaining;
const step = Math.min(segLen, need);
const t = step / segLen;
const nx = x0 + (x1 - x0) * t;
const ny = y0 + (y1 - y0) * t;

if (drawing) {
this.line.lineTo(nx, ny);
} else {
this.line.moveTo(nx, ny);
}

segLen -= step;
dashRemaining -= step;
gapRemaining -= step;
x0 = nx;
y0 = ny;

if (drawing && dashRemaining <= 0) {
drawing = false;
dashRemaining = dashLength;
gapRemaining = gapLength;
} else if (!drawing && gapRemaining <= 0) {
drawing = true;
dashRemaining = dashLength;
gapRemaining = gapLength;
}
}
}
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dashed line drawing algorithm has nested loops with potential performance concerns for long segments. The inner while loop processes segments pixel-by-pixel, which could be slow for very long line segments. Consider optimizing by calculating dash/gap positions mathematically rather than iteratively stepping through each pixel.

Copilot uses AI. Check for mistakes.
}>();
const MIN_DISPLAY_DB = -25;
const MAX_DISPLAY_DB = -1;
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MAX_DISPLAY_DB constant is set to -1, but the code clamps linear values to a maximum of 1.0 throughout (lines 271, 440, 468, 520, 566). Since 0dB corresponds to a linear value of 1.0, this creates an inconsistency - the display range shows up to -1dB but the actual values are clamped to 0dB. Consider either changing MAX_DISPLAY_DB to 0 or adjusting the clamping logic to match -1dB.

Suggested change
const MAX_DISPLAY_DB = -1;
const MAX_DISPLAY_DB = 0;

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

これは0dB相当周辺があまりよくなさそうなため、意図的なもの
コメントがないと意図がわからないと思われるためコメントを付与する

Comment on lines +137 to +138
if (y < 0 || y > height) {
y = height / 2;
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic for handling outside cursor position has potential issues. When Y is outside and clampOutsideY is true, Y is set to height/2 (line 138). However, this doesn't distinguish between top and bottom edges. For volume editing, maintaining the Y position at the boundary (0 or height) would be more intuitive than jumping to the middle. Consider clamping Y to the boundary instead.

Suggested change
if (y < 0 || y > height) {
y = height / 2;
if (y < 0) {
y = 0;
} else if (y > height) {
y = height;

Copilot uses AI. Check for mistakes.
return this.container;
}

constructor(options: VolumeLineOptions) {
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The VolumeLine class constructor doesn't document the expected range for the color alpha value. The color is of type Color with an alpha component (line 72 divides by 255), suggesting 0-255 range, but this should be explicitly documented in the class or type definition to prevent misuse.

Copilot uses AI. Check for mistakes.
Comment on lines +639 to +666
watch(
[
mounted,
phraseSignature,
phraseQuerySignature,
selectedTrackId,
() => selectedTrack.value?.volumeEditData,
volumePreviewEdit,
tempos,
timeSignatures,
tpqn,
numMeasures,
editorFrameRate,
],
([isMounted]) => {
asyncLock.acquire(
"volume",
async () => {
if (isMounted) {
await refreshVolumeSegments();
}
},
() => {
/* ignore */
},
);
},
);
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The watch callback checks multiple dependencies including 'mounted', but the watch is structured such that if any dependency changes, the entire refreshVolumeSegments operation runs. This could lead to redundant refreshes, especially when multiple related values change together (e.g., tempos and timeSignatures). Consider debouncing or batching these updates.

Copilot uses AI. Check for mistakes.
case "DRAW":
return "cursor-draw";
case "ERASE":
return "cursor-crosshair";
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cursor for the ERASE tool state is set to "cursor-crosshair" in both the ERASE case (line 197) and the default case (line 199). This differs from the pattern used in ScoreSequencer.vue which has a distinct "cursor-erase" style. For consistency and better UX, consider using "cursor-erase" for the ERASE tool state, and add the corresponding cursor style to SequencerVolumeEditor if needed.

Suggested change
return "cursor-crosshair";
return "cursor-erase";

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

実際には消しゴム状のカーソルがまだ用意できていないのであまり意味はないが、
いったん揃えておく。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants